Python 51.6%
TypeScript 46.7%
CSS 1.7%
1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { apiSafe, type Poll } from "@/lib/api";5import { PartyBadge } from "@/components/party";6import { Badge, Card, ConfidenceBadge, KV, SectionHeader, SourceBadge } from "@/components/ui";7import { PARTY_IDS, partyVar, partyLabel } from "@/lib/parties";8import { dateFr, dateTimeFr, delta, int, modeLabel } from "@/lib/format";910export const revalidate = 120;1112export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {13 const { id } = await params;14 const p = await apiSafe<Poll>(`/api/polls/${id}`);15 if (!p) return { title: "Sondage" };16 return { title: `${p.pollster.name} · ${dateFr(p.fieldworkEnd)} — Poll Inspector`, description: `Sondage ${p.pollster.name} (${dateFr(p.fieldworkEnd)}) : résultats, sous-échantillons, document original et impact sur le modèle QC26.` };17}1819export default async function Page({ params }: { params: Promise<{ id: string }> }) {20 const { id } = await params;21 const p = await apiSafe<Poll>(`/api/polls/${id}`);22 if (!p) notFound();23 const order = PARTY_IDS.slice().sort((a, b) => (p.results[b] ?? 0) - (p.results[a] ?? 0));24 const subs = p.subsamples ?? {};25 const dimLabel: Record<string, string> = { region: "Région", language: "Langue", age: "Âge", gender: "Genre" };26 return (27 <div className="space-y-10">28 <div>29 <div className="flex items-center gap-2 flex-wrap"><Link href="/sondages" className="text-[12.5px] text-ink-3 hover:text-ink">← Sondages</Link><Badge tone={p.sourceTier === "primary" ? "ok" : "warn"}>{p.sourceTier === "primary" ? "Document original archivé" : "Index tiers — document en attente"}</Badge><ConfidenceBadge value={p.confidence} />{p.excluded && <Badge tone="live">Exclu du modèle</Badge>}</div>30 <h1 className="display text-[34px] md:text-[44px] mt-3">{p.pollster.name}{p.sponsor && <span className="text-ink-3"> · {p.sponsor}</span>}</h1>31 <p className="text-ink-2 mt-2 text-[15px]">Terrain {p.fieldworkStart ? `du ${dateFr(p.fieldworkStart)} au ` : "jusqu'au "}{dateFr(p.fieldworkEnd)} · n = {int(p.sampleSize)}{p.marginOfError ? ` · ± ${p.marginOfError.toFixed(1).replace(".", ",")} pt` : ""}{p.mode ? ` · ${modeLabel(p.mode)}` : ""}</p>32 </div>33 <div className="grid lg:grid-cols-[1.2fr_1fr] gap-6">34 <Card>35 <div className="eyebrow mb-3">Intentions de vote · répondants décidés{p.resultsPage ? ` · page ${p.resultsPage}` : ""}</div>36 <div className="space-y-2.5">37 {order.map((pid) => (38 <div key={pid} className="grid grid-cols-[52px_1fr_60px] items-center gap-3">39 <span className="font-semibold num" style={{ color: partyVar(pid) }}>{partyLabel(pid)}</span>40 <div className="h-[18px] bg-surface-3 rounded-[4px] overflow-hidden"><div className="h-full" style={{ width: `${((p.results[pid] ?? 0) / 50) * 100}%`, background: partyVar(pid) }} /></div>41 <span className="num font-bold text-[17px] text-right">{p.results[pid] !== undefined ? p.results[pid].toFixed(1).replace(".", ",") : "—"}</span>42 </div>43 ))}44 {p.results["aut"] !== undefined && <div className="grid grid-cols-[52px_1fr_60px] items-center gap-3 text-ink-2"><span className="num">Autres</span><div /><span className="num text-right">{p.results["aut"].toFixed(1).replace(".", ",")}</span></div>}45 </div>46 {p.undecided !== null && <div className="text-[12.5px] text-ink-3 mt-3">Indécis / discrets : {p.undecided} % de l'échantillon (exclus des pourcentages ci-dessus).</div>}47 {p.weight?.impact && (48 <div className="mt-5 pt-4 border-t border-border">49 <div className="eyebrow mb-2">Impact sur QC26</div>50 <div className="grid grid-cols-5 gap-1.5 sm:gap-2">{PARTY_IDS.map((pid) => <div key={pid} className="surface-2 rounded-[6px] p-1.5 sm:p-2 text-center"><div className="text-[11px] font-semibold" style={{ color: partyVar(pid) }}>{partyLabel(pid)}</div><div className={"num font-bold " + ((p.weight!.impact![pid] ?? 0) > 0 ? "text-ok" : (p.weight!.impact![pid] ?? 0) < 0 ? "text-live" : "text-ink-3")}>{delta(p.weight!.impact![pid], 1)}</div></div>)}</div>51 <div className="text-[12px] text-ink-3 mt-2">Variation de la moyenne QC26 du jour attribuable à ce sondage (recalcul sans lui). Poids dans le run : {p.weight.weight.toFixed(3)} (récence {p.weight.recency.toFixed(2)} × échantillon {p.weight.sample.toFixed(2)}).</div>52 </div>53 )}54 </Card>55 <div className="space-y-4">56 <Card>57 <div className="flex items-center justify-between mb-3"><div className="eyebrow">Provenance</div><SourceBadge source={{ name: p.document ? "Document original" : "Index tiers", url: p.sourceUrl, archiveUrl: p.document?.archiveUrl, retrievedAt: p.document?.downloadedAt, page: p.resultsPage, tier: p.sourceTier }} /></div>58 <KV items={[["Firme", p.pollster.name], ["Commanditaire", p.sponsor ?? "—"], ["Publication", dateFr(p.publicationDate)], ["Population", p.population ?? "—"], ["Méthode", p.methodology ?? modeLabel(p.mode)], ["Original", p.sourceUrl ? <a className="link break-all" href={p.sourceUrl} target="_blank" rel="noopener noreferrer">{p.sourceUrl.replace(/^https?:\/\//, "").slice(0, 60)}…</a> : "—"], ["Archive QC26", p.document ? <a className="link" href={p.document.archiveUrl} target="_blank" rel="noopener noreferrer">{p.document.contentType?.includes("pdf") ? "PDF" : "HTML"} · {p.document.pages ?? "?"} p. · {(p.document.bytes / 1024).toFixed(0)} Ko</a> : "—"], ["SHA-256", p.document ? <span className="mono text-[11.5px] break-all">{p.document.sha256}</span> : "—"], ["Récupéré", p.document ? dateTimeFr(p.document.downloadedAt) : "—"], ["Extraction", p.extractionMethod], ["Ingéré", dateTimeFr(p.ingestedAt)]]} />59 </Card>60 <Card>61 <div className="eyebrow mb-2">Validation</div>62 <ul className="text-[13px] space-y-1">{(p.validation?.checks ?? []).map((c, i) => <li key={i} className="flex gap-2"><span className="text-ok">✓</span>{c}</li>)}{(p.validation?.warnings ?? []).map((c, i) => <li key={`w${i}`} className="flex gap-2"><span className="text-warn">!</span>{c}</li>)}</ul>63 {p.houseEffects && Object.keys(p.houseEffects).length > 0 && <div className="mt-3 text-[12.5px] text-ink-2">Effet maison {p.pollster.name} (pt) : {PARTY_IDS.map((pid) => `${partyLabel(pid)} ${delta(p.houseEffects![pid], 1)}`).join(" · ")}</div>}64 </Card>65 </div>66 </div>67 {Object.keys(subs).length > 0 && (68 <section>69 <SectionHeader eyebrow="Sous-échantillons" title="Ventilations publiées" description="Telles que rapportées dans le document ; les petits sous-échantillons ont une marge d'erreur élevée." />70 <div className="grid md:grid-cols-2 gap-4">71 {Object.entries(subs).map(([dim, segs]) => (72 <Card key={dim} pad={false} className="overflow-x-auto">73 <table className="data-table"><thead><tr><th>{dimLabel[dim] ?? dim}</th><th className="r">n</th>{PARTY_IDS.map((pid) => <th key={pid} className="r" style={{ color: partyVar(pid) }}>{partyLabel(pid)}</th>)}<th className="r">p.</th></tr></thead>74 <tbody>{Object.entries(segs).map(([seg, vals]) => (<tr key={seg}><td className="font-medium">{seg}</td><td className="r num text-ink-3">{vals._n ? int(vals._n as number) : "—"}</td>{PARTY_IDS.map((pid) => <td key={pid} className="r num">{vals[pid] !== undefined && vals[pid] !== null ? Number(vals[pid]).toFixed(0) : "—"}</td>)}<td className="r num text-ink-3">{vals._page ?? "—"}</td></tr>))}</tbody></table>75 </Card>76 ))}77 </div>78 </section>79 )}80 {p.leadership && p.leadership.length > 0 && (81 <section>82 <SectionHeader eyebrow="Leadership" title="Autres questions du rapport" description="Questions de leadership/confiance telles que posées par la firme ; QC26 ne mélange jamais des libellés différents entre sondages." />83 <div className="grid md:grid-cols-2 gap-4">{p.leadership.map((q, i) => (<Card key={i}><div className="font-semibold text-[14px] mb-2">{q.question} <span className="text-ink-3 text-[12px]">p. {q.page}</span></div><div className="space-y-1.5">{Object.entries(q.values).sort((a, b) => b[1] - a[1]).map(([k, v]) => (<div key={k} className="grid grid-cols-[1fr_auto] gap-3 text-[13px]"><span>{k}</span><span className="num font-semibold">{v} %</span></div>))}</div></Card>))}</div>84 </section>85 )}86 {p.audit && p.audit.length > 0 && (87 <section><SectionHeader eyebrow="Journal d'audit" title="Historique des modifications" /><Card pad={false} className="overflow-x-auto"><table className="data-table min-w-[560px]"><thead><tr><th>Quand</th><th>Qui</th><th>Champ</th><th>Raison</th></tr></thead><tbody>{p.audit.map((a, i) => <tr key={i}><td className="num whitespace-nowrap">{dateTimeFr(a.ts)}</td><td className="whitespace-nowrap">{a.actor}</td><td>{a.field}</td><td className="text-ink-2">{a.reason}</td></tr>)}</tbody></table></Card></section>88 )}89 <div className="flex gap-2"><PartyBadge id={order[0]} /> <span className="text-[13px] text-ink-2">en tête dans ce sondage (répondants décidés).</span></div>90 </div>91 );92}93